﻿using CN2P.Recommendations.DomainModel;
using CN2P.Recommendations.Repository.Interfaces;
using Neo4jClient;
using Neo4jClient.Cypher;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace CN2P.Recommendations.Repository.Impl
{
    public class Neo4jRepositoryImplementation : IProductRecommendationRepository
    {
        private const string PRODUCTREFERENCEINDEXNAME = "productReference_Id_Idx";

        private Uri _graphDbUri = null;

        /// <summary>
        /// Makes sure the initalization of the repository has happened before any methods are called
        /// </summary>
        public bool IsInitialized { get; private set; }

        public Neo4jRepositoryImplementation(Uri graphDbUrl)
        {
            IsInitialized = false;
            _graphDbUri = graphDbUrl;
        }

        #region Private Helper Methods

        /// <summary>
        /// Initializes basic settings for the graph DB including indexes
        /// </summary>
        public void Initialize()
        {
            try
            {
                if (IsInitialized) return;

                var graphProxy = new GraphClient(_graphDbUri);
                graphProxy.Connect();
                if (!graphProxy.CheckIndexExists(PRODUCTREFERENCEINDEXNAME, IndexFor.Node))
                {
                    graphProxy.CreateIndex
                        (
                            PRODUCTREFERENCEINDEXNAME,
                            new IndexConfiguration()
                            {
                                Provider = IndexProvider.lucene,
                                Type = IndexType.exact
                            },
                            IndexFor.Node
                        );
                }

                // Intialization happened
                IsInitialized = true;
            }
            catch (Exception ex)
            {
                throw new ApplicationException("Initialization of the Neo4j database (creation of index and the like) failed in Neo4jRepositoryImplementation. Please check inner exception for details!", ex);
            }
        }

        /// <summary>
        /// Creates a new product reference with an index in the GraphDB
        /// </summary>
        /// <param name="result"></param>
        /// <param name="graphProxy"></param>
        private static NodeReference<ProductReference> CreateNewProductReferenceWithIndex(ProductReference result, GraphClient graphProxy)
        {
            var newNodeRef = graphProxy.Create<ProductReference>(result);
            graphProxy.ReIndex
                (
                    newNodeRef,
                    new[] { 
                        new IndexEntry
                            (
                                Neo4jRepositoryImplementation.PRODUCTREFERENCEINDEXNAME) 
                                { 
                                    { "prodId", result.ProductId } 
                                } 
                    }
                );
            return newNodeRef;
        }

        #endregion

        /// <summary>
        /// Helper class for the async execution in method GetRecommendations (quick-fix - will use query optimization in future fix)
        /// </summary>
        private class TaskHelperClass
        {
            public ProductReference prodRef { get; set; }
        }

        /// <summary>
        /// Return recommended products for those products that are passed in
        /// </summary>
        public IList<DomainModel.ProductReference> GetRecommendations(params string[] productIds)
        {
            Initialize();

            List<ProductReference> result = new List<ProductReference>();

            try
            {
                var graphProxy = new GraphClient(_graphDbUri);
                graphProxy.Connect();

                //
                // Execute queries against Node4j for every product to return its recommendations in parallel (async)
                //
                var taskResults = new List<Task<IEnumerable<TaskHelperClass>>>();
                foreach (var currentId in productIds)
                {
                    string matchString = string.Format("p1-[r:{0}]->p2", ProductRecommendationRelationship.TypeKey);
                    var resultsQuery = graphProxy.Cypher.Start(new { p1 = All.Nodes })
                                                        .Match(matchString)
                                                        .Where(string.Format("p1.prodId! = '{0}'", currentId))
                                                        .Return(p2 => new TaskHelperClass() { prodRef = p2.As<ProductReference>() })
                                                        .OrderByDescending("r.weight");

                    taskResults.Add(resultsQuery.ResultsAsync);
                }

                //
                // Wait for all async queries to be finished
                //
                Task.WaitAll(taskResults.ToArray());

                //
                // Finally compose the results
                //
                foreach (var r in taskResults)
                {
                    // This can become more beautiful with query optimization, as well. But today is a public holiday
                    // here in Austria, so I will cover this tomorrow!!
                    foreach (var ri in r.Result)
                    {
                        if (result.FirstOrDefault(x => x.ProductId == ri.prodRef.ProductId) == null)
                        {
                            result.Add(ri.prodRef);
                        }
                    }
                }
            }
            catch (Exception ex)
            {
                throw new Exception("Unable to get recommendations. Please look at diagnostics data for further details!", ex);
            }

            return result;
        }

        /// <summary>
        /// Adds a recommendation from one product to another
        /// </summary>
        public DomainModel.ProductRecommendation AddRecommendationTraversal(string fromProductId, string toProductId)
        {
            Initialize();

            var result = default(ProductRecommendation);

            try
            {
                var graphProxy = new GraphClient(_graphDbUri);
                graphProxy.Connect();

                NodeReference<ProductReference> fromProductRef = null;
                NodeReference<ProductReference> toProductRef = null;

                //
                // First of all find the two products and create them if they do not exist
                //
                var fromProduct = graphProxy.Cypher.Start(new { p = Node.ByIndexLookup(PRODUCTREFERENCEINDEXNAME, "prodId", fromProductId) })
                                                   .Return( p => new { prod = p.Node<ProductReference>() }).Results.FirstOrDefault();
                if (fromProduct == null)
                    fromProductRef = CreateNewProductReferenceWithIndex(new ProductReference() { ProductId = fromProductId, AverageRating = 0 }, graphProxy);
                else
                    fromProductRef = fromProduct.prod.Reference;

                var toProduct = graphProxy.Cypher.Start(new { p = Node.ByIndexLookup(PRODUCTREFERENCEINDEXNAME, "prodId", toProductId) })
                                                 .Return(p => new { prod = p.Node<ProductReference>() }).Results.FirstOrDefault();

                if (toProduct == null)
                    toProductRef = CreateNewProductReferenceWithIndex(new ProductReference() { ProductId = toProductId, AverageRating = 0 }, graphProxy);
                else
                    toProductRef = toProduct.prod.Reference;

                //
                // Now add the relationship between the products if it does not exist. If it does exist, increase the weight
                //
                string matchString = string.Format("f-[rd:{0}]->t", ProductRecommendationRelationship.TypeKey);
                var prodRelationship = graphProxy.Cypher.Start(new { f = fromProductRef, t = toProductRef })
                                                        .Match(matchString)
                                                        .Return(
                                                            rd => new {
                                                                relRef = rd.As<RelationshipReference<ProductRecommendationRelationship>>(),
                                                                relData = rd.As<ProductRecommendationRelationship>()
                                                            }
                                                         ).Results.FirstOrDefault();

                if (prodRelationship == null)
                {
                    result = new ProductRecommendation()
                                    {
                                        SourceProductId = fromProductId,
                                        TargetProductId = toProductId,
                                        Weight = 1
                                    };

                    graphProxy.CreateRelationship<ProductReference, ProductRecommendationRelationship>
                        (
                            fromProductRef, 
                            new ProductRecommendationRelationship
                                (
                                    toProductRef, 
                                    result
                                )
                        );
                }
                else
                {
                    graphProxy.Update<ProductRecommendationRelationship>
                        (
                            prodRelationship.relRef,
                            rel => { 
                                ((ProductRecommendation)rel.Data).Weight++;
                                result = ((ProductRecommendation)rel.Data);
                            }
                        );
                }
            }
            catch (Exception ex)
            {
                throw new Exception("Unable to add recommendation traversal for products. Please look at diagnostics data for further details!", ex);
            }

            return result;
        }

        /// <summary>
        /// Submits a new product rating
        /// </summary>
        public DomainModel.ProductReference SubmitProductRating(string productId, float rating)
        {
            Initialize();

            var result = new ProductReference() { ProductId = productId, AverageRating = rating };

            try
            {
                var graphProxy = new GraphClient(_graphDbUri);
                graphProxy.Connect();

                //
                // First of all find the product to be updated
                //
                var targetProduct = graphProxy.Cypher.Start(new { p = Node.ByIndexLookup(PRODUCTREFERENCEINDEXNAME, "prodId", productId) })
                                                     .Return(p => new { prod = p.Node<ProductReference>() }).Results.FirstOrDefault();
                if (targetProduct == null)
                    CreateNewProductReferenceWithIndex(result, graphProxy);
                else
                    graphProxy.Update<ProductReference>(targetProduct.prod.Reference, result);
            }
            catch (Exception ex)
            {
                throw new Exception("Unable to submit a new rating for the product. Please look at diagnostics data for further details!", ex);
            }

            return result;
        }
    }
}
